Skip to content

feat(service-automation): a run record says how long it waited (waitMs), so a resumed screen flow no longer reads its user's wait as work - #33511

Queued
objectstack-fleet[bot] wants to merge 2 commits into
mainfrom
claude/area-A5-32284
Queued

objectstack-fleet[bot] wants to merge 2 commits into
mainfrom
claude/area-A5-32284

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

Fixes #32284

Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: none — the added member is a field of the run record the platform answers; no metadata author writes it, and no skill teaches a run's timing

维护者速读

  • 改了什么:流程运行记录(ExecutionLog,即运行详情与运行列表返回的那条)新增可选字段 waitMs:运行挂起等待(screen / approval / wait 等节点)的总时长,多次暂停累加;durationMs 含义不变(总耗时,含等待),实际执行时间 = durationMs - waitMs。
  • 为什么改:恢复后的 screen 流程报 durationMs: 18691,几乎全是用户在等,管理员分不清"流程慢"和"在等人"。
  • 风险与回滚:只加可选字段,运行行为不变;sys_automation_run 新增两列 active_ms(暂停时已执行的时长,即等待时钟)与 wait_ms(终态行的等待总和),旧行为空即"未记录"。回滚即 revert 本 PR,两列留空无害。
  • 先看:packages/services/service-automation/src/run-resume.ts(恢复时结算等待、再次暂停时把时钟前移);packages/services/service-automation/src/engine.ts(SuspendedRun.activeMs、waitOfPausedRun、activeMsAtPause);packages/services/service-automation/src/sys-automation-run.object.ts(两列新增,引擎自有对象上的存储面变化)。
  • 两分钟验证:pnpm --filter @objectstack/service-automation exec vitest run src/run-wait-time.test.ts,预期全绿;其中两次暂停的用例断言终态 waitMs 等于两段等待之和,且 durationMs - waitMs 等于执行时间。
  • 需要你做的:契约复审时确认 sys_automation_run 新增的两列属于裁决 A 的范围(该对象的代码注释把加列列为人工裁定面)。

What changes

  • @objectstack/spec: ExecutionLogSchema gains waitMs?: number (int, min 0) beside durationMs, exactly the one optional member ruling 6879411530 (option A) allows. durationMs keeps its meaning (total wall time, pauses included); its describe text is untouched. Readers derive active time as durationMs - waitMs.
  • @objectstack/service-automation: every run-record writer fills it.
    • Initial execution (execute() and the retry path executeWithoutRetry()): completed, failed, refused and the first paused entry carry waitMs: 0.
    • A suspension now carries one wait clock, SuspendedRun.activeMs: the time the run had spent executing when it paused. Nothing executes while a run is parked, so at any later instant the run's total wait is its age minus that number. That is what sums every pause of a multi-stage run without a list of intervals: the resume computes waitMs once, at the moment it wins the advance claim (waitOfPausedRun), and a re-suspend writes the clock forward (activeMsAtPause).
    • Resume path (resumeInternal): completed, refused, stranded (failed) and re-suspended records carry the summed wait. A resume refused before the claim (lost claim, invalid screen input, a subflow child still parked) consumes nothing, so the run keeps waiting on the same clock.
    • A paused run that ends where it waits (cancelRun, failSuspendedRun, settleUnresumableSuspension): everything since its pause counts as wait.
    • Restore (consumed-suspension-restore.ts): the re-armed snapshot keeps the clock of the pause it consumed, so the failed attempt, the time stranded and the wait to the next resume count as one pause. That matches the snapshot's step log, which ends at the pause.
    • Persistence: sys_automation_run gains active_ms (paused rows, and the consumed snapshot on a stranded terminal row) and wait_ms (terminal rows). ObjectStoreSuspendedRunStore writes and reads both; the in-memory store round-trips them by JSON clone; runRecordToLogEntry serves waitMs after a restart.
  • Generated: packages/spec/authorable-surface/automation.json (+1 key) and content/docs/references/automation/execution.mdx, from check:generated --fix. authorable-surface.base.json is untouched.
  • One changeset, minor for both packages, with both declaration lines.

Decisions taken on the dispatch's axes (correctness for an admin first, then smallest published surface, then effort)

  1. 0 for a run that never paused, absent for "not recorded". The dispatch's default was absent; the neighbouring run-level totals say otherwise. FlowRunSummary.failed and .unmeasured are written as 0 when measured and are absent only when not tracked ("absent = not tracked (an older run), which is not the same as zero"). Following that: waitMs: 0 is a measured fact, and absent covers a missed entry (nothing ran, durationMs is absent too), a run paused before this release, and a host-supplied store that does not round-trip the clock. With absent-for-never-paused, those unrecorded cases would read as "never waited", which is the 18.7 s misreading this card exists to fix; and every reader would need waitMs ?? 0, a consumer fallback.
  2. New persistence was unavoidable; one clock column, not an interval list. No suspension start or stop time exists today. The suspension row records only the run's started_at / start_time, as the engine's own suspendedRunToLogEntry docblock states ("a suspension row records no pause instant"). created_at is the first pause only, updated_at is moved by any re-save of the same pause (a subflow parent's screen re-persist), and the in-memory default store has neither. So the clock is one number per suspension (active_ms) plus the published total on the terminal row (wait_ms). Two columns rather than one keep the stranded row's consumed-snapshot clock apart from the failed record's own wait, so a restore after a restart still sums correctly.
  3. Where a pause ends: the advance claim. For a subflow parent, the child's post-resume execution therefore counts as the parent's wait on both resume routes (through the parent, or the child's up-bubble). One rule, independent of the route; the child's own record carries its own split.

Tests

This body is written once, at create; every reading taken after it (suite VERDICT lines, typecheck, the generated-artifact check, the derived gates and the ablation) is in the os-dev report comment on #32284, each naming the head it was taken on.

  • packages/spec/src/automation/execution.test.ts: waitMs kept through a parse beside durationMs, absent stays absent (never defaulted to 0), negative and fractional values refused at ['waitMs'].
  • packages/services/service-automation/src/run-wait-time.test.ts (new; time driven with vi.setSystemTime, Date only): a run that never paused reads waitMs: 0; one pause resumed later reads exactly that wait; two pauses sum; cancel and restore paths; each record parsed through ExecutionLogSchema, so an undeclared member would be stripped and the case would go red.
  • packages/services/service-automation/src/suspended-run-store.test.ts (new top-level describe on the shared createFakeEngine harness): a two-pause run driven across three engine instances over one sys_automation_run table sums both waits; active_ms and wait_ms land in their columns and read back; a row without the columns reads "not recorded".
  • Ablation (suspension sum forced to 0): in the report comment.

Acceptance notes

  • AutomationResult (the trigger and resume responses) is unchanged: it keeps its durationMs and gains nothing, because a second published member is outside the ruling. The run record (getRun, listRuns) is where waitMs is read.
  • A paused run read back from its suspension row (after a restart) still carries neither durationMs nor waitMs: active_ms is a clock, not a pause instant, so the docblock's "a suspension row records no pause instant" stays true.
  • The run-summary log line and its meta are unchanged.
  • The hand-written content/docs/automation/flows.mdx run-history section could name waitMs; it is outside this PR's file surface.

Verification

In the os-dev report comment on #32284 (local subset, reconciled with dispatch-gates --ran), and in this PR's check-runs.

Seat: domain:server#5 · os-elon-musk · session_016WV4cxHwXNcGcAHNerZsom, dispatched to an os-dev subagent on claim 6885364290.

🤖 Generated with Claude Code

Adds an optional waitMs to the published run record (ExecutionLog) and
fills it on every run-record writer: 0 for a run that never paused, the
sum of every pause for a resumed run. The engine keeps one wait clock
per suspension (activeMs, persisted as sys_automation_run.active_ms) and
the terminal row carries wait_ms.

Claude-Session: https://claude.ai/code/session_016WV4cxHwXNcGcAHNerZsom
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-automation, @objectstack/spec, touching 25 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/automation.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-handling-client.mdx (via waitMs (symbol, a field of interface ExecutionLogEntry; a field of interface RunRecord))
  • content/docs/automation/flows.mdx (via cancelRun (symbol, a top-level function))
  • content/docs/kernel/cluster.mdx (via waitMs (symbol, a field of interface ExecutionLogEntry; a field of interface RunRecord))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/automation.json) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 172 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e40cd1e28dd79e60e7f4be0db06f071f3f962574 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5be6ef95e0e5d23ae5be87224c5258a58b1b8e9b — the merge of head 50c4166cabb364947c6aab50739e07eb4eeefb17 into base e40cd1e28dd79e60e7f4be0db06f071f3f962574, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5be6ef95e0e5d23ae5be87224c5258a58b1b8e9b && git checkout 5be6ef95e0e5d23ae5be87224c5258a58b1b8e9b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e40cd1e28dd79e60e7f4be0db06f071f3f962574 50c4166cabb364947c6aab50739e07eb4eeefb17 && git checkout -B drift-repro e40cd1e28dd79e60e7f4be0db06f071f3f962574 && git merge --no-ff 50c4166cabb364947c6aab50739e07eb4eeefb17

node scripts/docs-audit/affected-docs.mjs --json e40cd1e28dd79e60e7f4be0db06f071f3f962574

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e40cd1e28dd79e60e7f4be0db06f071f3f962574 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…he restore

Pins the summed wait on every run-record path: a run that never paused,
one pause, two pauses across fresh engines over one store, a cancel
where the run waits, a stranded resume restored by an operator, and a
suspension parked without a clock. A second group drives the object
store over one table and reads the active_ms and wait_ms cells, so a
mapper that never wrote a column cannot pass.

Regenerates the authorable-surface baseline and the reference docs, and
adds the changeset.

Claude-Session: https://claude.ai/code/session_016WV4cxHwXNcGcAHNerZsom
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface and removed size/m labels Nov 14, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4cxHwXNcGcAHNerZsom

Request: review · area A5 · head 50c4166 · 2026-11-14T23:49Z

Contract review: Fixes #32284 under the maintainer ruling A (comment 6879411530). Clause-②: yes (widening) (optional ExecutionLog.waitMs), Metadata-protocol: added. Governed check: NOT governed, 659 lines. CI at request time: 56 success, 10 skipped, Test Core (8/8) still running. os-dev report: comment 6886115077 on #32284. Open question for the reviewer: two nullable number columns on the engine-owned sys_automation_run (active_ms wait clock on a paused or stranded row, wait_ms total on a terminal row), needed so the value survives a resume in a different process. Are they inside ruling A (report recommends keeping both)? Also note the convention choice: a run that never paused carries waitMs: 0, absent means not recorded (mirrors FlowRunSummary.failed / .unmeasured).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract-review-claim: session_016WV4cxDxAYxdtDhtR1kQQe · head 50c4166 · 2026-11-14T23:49:49Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 50c4166cabb364947c6aab50739e07eb4eeefb17
Local-runs: none
Scope: packages/spec/src/automation/execution.zod.ts + packages/spec/authorable-surface/automation.json, packages/services/service-automation/src/engine.ts, packages/services/service-automation/src/run-resume.ts, packages/services/service-automation/src/dag-traversal.ts, packages/services/service-automation/src/consumed-suspension-restore.ts, packages/services/service-automation/src/run-history.ts, packages/services/service-automation/src/suspended-run-store.ts, packages/services/service-automation/src/sys-automation-run.object.ts

review#2 (server area pilot) · area A5 · Fixes #32284 · maintainer ruling A (6879411530) · published-schema face. CI on this head at review time: 56 success, 10 skipped, 1 in progress, none failing.

① Derived judgments

  • Spec. ExecutionLogSchema gains waitMs: z.number().int().min(0).optional(). Its describe matches the semantics the engine writes:

    • durationMs stays the total wall time.
    • waitMs is included in it and summed over every pause.
    • 0 means the run never paused.
    • Absent means not recorded.

    The authorable-surface baseline gains exactly ExecutionLog:waitMs.

  • One clock, SuspendedRun.activeMs. It holds the run's executed time at the pause, which is its age minus the wait already accumulated. Every later moment derives the total wait as age - activeMs (waitOfPausedRun), clamped at 0. The clamp exists because startTime may come from another replica's clock. Re-suspending writes the clock forward (activeMsAtPause(durationMs, waitMs)), overriding the ...run spread, so N pauses sum without a list. A suspension with no clock propagates undefined, never a guessed number.

  • Every writer of a run record sets the member:

    • Initial execution and each retry attempt (0).
    • First pause (0, plus the clock).
    • Resume, computed once where the claimed pause is consumed and carried to completion, refusal, failure and re-suspend.
    • A paused run that is failed, settled or cancelled where it waits.
    • The operator restore, which keeps the snapshot's clock, so the stranded interval counts as wait.

    A refused resume (lost claim, invalid screen, child still parked) returns before the consume line, so its pause keeps one clock. That matches the comment.

  • Durable path.

    • wait_ms is written on the upsert as ?? null, so a rewrite cannot inherit a stale wait, and is read back only when it is a number.
    • active_ms is written on the paused row and on a terminal row's consumed snapshot, and read by both deserialisers via readActiveMs.
    • runRecordToLogEntry carries waitMs, so the served record after a restart or a ring eviction matches the ring.
  • Tests cover the cases that matter, and the ablation gives 6 red:

    • never paused gives 0;
    • one pause gives durationMs - waitMs equal to the work;
    • two pauses across two restarts sum;
    • cancel where it waits;
    • restore keeps the clock;
    • no clock leaves the member absent.

② Semver level

③ Boundary flags

  • Not security sensitive. waitMs is a duration on a record its caller can already read.
  • Two nullable number columns on the engine-owned sys_automation_run. That is platform storage, not authored metadata, so Metadata-protocol is not moved by them. Schema sync adds them. No migration is owed: rows written before this release read waitMs as not recorded, which is the declared meaning of absent, and no legacy-data migration is owed under the current release policy.
  • No cross-area file: service-automation and packages/spec/src/automation/** are A5's lease.

④ Design judgment

Rulings on the two questions from the os-dev report (6886115077):

  • Q1: both columns are inside ruling A; keep them. Ruling A puts waitMs on the published run record. The card's own case is a screen flow resumed by a different process than the one that paused it, and it is answered from the durable row.
    • Without active_ms the resuming process cannot know when the pause began, so the value would be absent exactly where the card needs it.
    • Without wait_ms a run read back after a restart or a ring eviction would lose it.
    • Both columns are the storage of the ruled member. They add no second member and no second meaning, and durationMs is untouched.
    • The single-clock design (activeMs, not a list of pause instants) is the minimal state that sums any number of pauses across restarts and replicas.
  • Q2: keep 0 = measured, absent = not recorded. It matches FlowRunSummary.failed/.unmeasured in the same file, and it keeps a pre-release paused run from reading as "never waited". The spec describe and the jsdoc both say "absent is not zero".

Nits (none blocking):

  • N1: a paused entry's waitMs is frozen at the moment it was written, so a run paused for hours on its first pause reads waitMs: 0 in the list until it resumes. The describe says so ("the wait up to the moment the entry was written"). Noted only so nobody files it as a bug.
  • N2: the restore path counts the failed resume attempt's own execution time as wait. The comment says so, and the step log ends at the pause, so the derived "executing" figure matches what the record shows. This is the right call; it is noted only for the docs page.

Implemented-by: claude/area-A5-32284
Reviewed-by: session_016WV4cxDxAYxdtDhtR1kQQe

VERDICT: PASS

Nits only, so this is the final round. Land once the last check is green.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4cxHwXNcGcAHNerZsom

ACCEPT · area A5 · head 50c4166 · 2026-11-14T23:51Z

  • Cards: Fixes automation run record: a resumed run's durationMs counts the time it waited for the user, so a screen flow reads as 18.7 s of work #32284 (maintainer ruling A, comment 6879411530).
  • Review: review#2 contract review PASS at this head (comment 6886157000). Clause-②: yes (widening) and Metadata-protocol: added ruled right; Q1 the two nullable sys_automation_run columns (active_ms, wait_ms) are the storage of the ruled member and stay; Q2 0 = measured, absent = not recorded. Nits N1/N2 are documentation notes only (no card).
  • Governed: check-governed-merges.mjs --pr 33511 → NOT governed, 659 changed lines.
  • Checks: all green on this head; mergeable_state clean.
  • Landing: ready + auto-merge under the maintainer's standing area rule (15:21Z, A5 thread).

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 14, 2026 23:52
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 14, 2026
Any commits made after this event will not be merged.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

automation run record: a resumed run's durationMs counts the time it waited for the user, so a screen flow reads as 18.7 s of work

2 participants